Skip to content

Pt97 compliance, Add strict gatekeeper mode for Meshtastic/MeshCore ingress#17

Open
mathisono wants to merge 58 commits into
kn6plv:mainfrom
mathisono:pt97-compliance
Open

Pt97 compliance, Add strict gatekeeper mode for Meshtastic/MeshCore ingress#17
mathisono wants to merge 58 commits into
kn6plv:mainfrom
mathisono:pt97-compliance

Conversation

@mathisono

Copy link
Copy Markdown

Summary

This PR adds an optional Strict Gatekeeper mode for Raven bridge deployments that forward Meshtastic or MeshCore text traffic into AREDN.

The goal is to make the bridge fail closed for common amateur-radio compliance risks when crossing from an unlicensed mesh into an AREDN / amateur-radio network.

What changed

  • Adds a new gatekeeper.uc policy module.
  • Adds strict_gatekeeper configuration support:
{
  "strict_gatekeeper": {
    "enabled": true,
    "gateway_callsign": "W6XYZ",
    "allowed_callsigns": ["KN6PLV", "KJ6DZB"]
  }
}
  • Validates sender identity by extracting a US-style callsign token from node names.
  • Supports an optional allowed_callsigns whitelist.
  • Drops encrypted Meshtastic packets before the legacy decrypt path is reached.
  • Drops non-text Meshtastic/MeshCore bridge ingress in strict mode.
  • Rewrites accepted bridged text to originate from the gateway node instead of spoofing the remote Meshtastic/MeshCore sender.
  • Prefixes bridged text as:
[SENDER via GATEWAY] message

For example:

[KN6PLV via W6XYZ] Hello
  • Adds a router-level defense-in-depth filter so future transport changes do not accidentally enqueue non-compliant bridge ingress.
  • Adds STRICT_GATEKEEPER.md documenting configuration, behavior, and operational caveats.
  • Links the new guide from the README.

Rationale

Meshtastic and MeshCore are useful companion meshes, but AREDN runs in a licensed amateur-radio environment where cleartext operation, station identification, and operator control matter.

The previous behavior allowed bridge traffic to be forwarded too openly. This PR introduces an opt-in mode that:

  1. rejects encrypted Meshtastic ingress before decryption;
  2. requires the apparent sender to match a callsign-shaped identity and optional whitelist;
  3. injects the final AREDN message as the gateway station, with the remote operator identified in the message body.

This preserves existing non-strict behavior by default while providing a safer operating mode for regulated deployments.

Notes and caveats

Strict Gatekeeper mode is not cryptographic identity proof. Meshtastic and MeshCore names are user-controlled, so a node can be renamed to look like a valid callsign. For real deployments, operators should use allowed_callsigns, and a later hardening step should bind allowed operators to stable Meshtastic node IDs or MeshCore public keys.

The callsign validator intentionally matches a simple US amateur callsign form: one or two letters, one numeral, and one to three letters. This will reject international callsigns or unusual/special-event formats that do not contain an extractable US-style token.

Dropped packet logging uses the existing DEBUG0 and DEBUG1 channels, so headless OpenWrt deployments should confirm service/logd capture before relying on logs for troubleshooting.

Testing

  • Not yet tested on live RF hardware in this PR.

  • Code was reviewed for ingress chokepoints:

    • Meshtastic encrypted packets are rejected in meshtastic.uc before decrypt branches run.
    • Meshtastic/MeshCore ingress is filtered before router queue admission.
    • Accepted text is rewritten to use the gateway node identity.

Follow-up work

MeshCore direct-message handling currently still performs protocol-level decryption inside meshcore.uc before the router-level strict filter sees the decoded text. The router filter prevents non-text or non-rewritten MeshCore ingress from entering the queue, but the strictest future improvement would be to add a MeshCore decoder-level gate similar to the Meshtastic encrypted-packet hard stop.

1. Add exponential backoff for APRS backend reconnection (5s base,
   5min cap) to avoid hammering unreachable servers every poll cycle.

2. Fix recv() to buffer all messages from a single read instead of
   returning only the first match. Remaining messages are drained via
   tick() -> router.queue() on the next cycle, and recv() checks the
   pending buffer before reading more data from the socket.

3. Separate kiss_rxbuf from rxbuf so KISS unframing and TNC2 line
   buffering never collide.

4. Send APRS ACK for every inbound message that carries a message-id,
   so the remote station knows Raven received the packet.
mathisono pushed a commit to mathisono/Raven that referenced this pull request Jun 3, 2026
- Add strict gatekeeper mode for Meshtastic/MeshCore ingress (Part 97 compliance)
- Add APRS bridge with APRS-IS, KISS TCP, and TCP text backends
- APRS hardening: reconnect backoff, buffered recv, inbound ACK
@aanon4 aanon4 added the AI used label Jun 3, 2026
…stic

ucode's module resolver rejects cycles in the import graph. The chain
config→router→meshtastic→gatekeeper, with config also importing
gatekeeper directly, triggers a circular dependency error.

Break the cycle by:
- Removing 'import gatekeeper' from both router.uc and meshtastic.uc
- Adding router.setGatekeeper() to inject the reference at setup time
- Passing gatekeeper via config._gatekeeper for meshtastic.uc
- Using optional chaining (gatekeeper?.isEnabled()) so both modules
  work safely before the reference is injected
builder_bob added 6 commits June 2, 2026 19:50
When the APRS channel is selected, the input placeholder shows
'@callsign message  or  #group message ...' so users know the
direct-message and group syntax without reading docs.
- aprs.process() was empty, so outbound messages from UI never reached
  APRS-IS. Now calls send() for locally-originated messages.
- Regex for parsing message IDs didn't handle APRS 1.1 reply-ack
  trailing '}', causing {01} to appear in message text and ACKs
  not being sent back (Xastir kept retransmitting).
…eed channel

- Inbound messages from callsigns with a DM channel (e.g. kj6dzb-4 og==)
  now route to that DM channel instead of always going to the group feed.
- Group members always route to the main APRS feed channel.
- Outbound from DM channels auto-routes to the callsign in the channel
  name, stripping redundant @callsign prefix if present.
- Default feed channel renamed to APRS-IS-Feed (no spaces in namekey).
- Feed channel is auto-registered at setup even if not in config.
- Channel key extracted once at setup for efficient DM matching.
…nly, wire up gatekeeper ref in meshcore

- meshcore.uc: accept gatekeeper reference from config, add early-drop comment for encrypted packets
- router.uc: when gatekeeper strict, only bridge text_message outbound to MeshCore and Meshtastic
- Remove old r12 package artifacts
- aprs.uc: refactor single backend to named backend registry; each
  backend gets own socket, rxbuf, reconnect state; backward compat
  with old aprs.backend (singular) config; per-channel backend
  binding via channelBackendMap; new exports: getBackendNames(),
  updateChannelBackend(), sendToGroup(), recv(backendName)

- groups.uc: new shared module extracted from aprs.uc; group CRUD
  (getGroup/putGroup/removeGroup/memberOf/allGroups), canRepeat()
  rate-limit/dedup, parseJoinArgs() command parser,
  createGroupChannel() for runtime channel creation

- commands.uc: new /join, /leave, /groups, /help slash commands;
  /join #name creates shared-key channel (Meshtastic+MeshCore+AREDN);
  /join %name creates AREDN-only channel;
  /join #name CALL1 CALL2 msg creates APRS group + channel + sends;
  /join #name backend=NAME CALL1 msg binds to specific backend;
  callsigns force AREDN-only (Part 97 compliance);
  /leave removes channel + APRS group; /groups lists groups

- router.uc: poll multiple APRS sockets via aprs:backendName tags;
  default case dispatches to aprs.recv(backendName)

- config.uc: wire up groups.setup(); persist backend field in
  channel overrides

- channel.uc: store optional backend property on channel objects

- event.uc: send aprs_backends list to UI; update channel-backend
  bindings on newchannels

- ui/ui.js: backend dropdown in Configure Channels (only shown
  when backends configured); typeChannelBackend() handler;
  aprsBackends global populated from server

- ui/ui.css: column width for Backend header

- APRS.md: complete rewrite with all slash commands, chat commands,
  config reference, backend types, multi-backend examples

- docs/JOIN_CROSSPLATFORM_ANALYSIS.md: cross-platform join analysis
builder_bob and others added 28 commits June 4, 2026 20:42
- Fix: callsign regex now requires at least one digit, preventing
  plain English words (radio, check, hello) from being parsed as
  callsigns in /join and in-chat group commands
- New: /backends slash command lists configured APRS backends
- Docs: added /backends to APRS.md and /help output
- Docs: added AREDN-Only Channels section explaining Part 97
  channel separation and prefix conventions
ucode is not JavaScript — it has no global 'undefined'. Unset
properties return null. Using '!== undefined' caused a runtime
Reference error crashing /join when putGroup() or setLocalChannel()
was called.

Fixed in groups.uc putGroup() and channel.uc setLocalChannel().
The og== key is what marks a channel as AREDN-only, not the %
prefix. Converting #me to %me confused users. Now /join #me CALL1
msg creates '#me og==' — the name the user typed, with the
AREDN-only key.
- /channels world: show 'Requesting...' feedback when bridge found,
  show 'No bridge available' when none exists (was silent)
- /backend: alias to /backends (user tried singular form, got nothing)
- Unknown commands: reply with helpful error instead of silent drop
- process() reply_public_channels: add missing break before default
Two issues prevented outbound messages from group channels:

1. /join without explicit backend= never registered the channel in
   channelBackendMap, so process() silently dropped outbound messages.
   Now always binds to default backend.

2. Plain text in a group channel (e.g. #me) was sent to default_group
   instead of the channel's own group. Now resolves the group from
   the channel name first, falling back to default_group.
updateChannelBackend(namekey, null) from /join (no explicit backend)
was hitting the 'explicitly cleared' branch and removing the channel
from channelBackendMap. Now null means 'bind to default' and only
empty string means 'explicitly clear'.
- Channel names keep user's prefix (no more # to % swap)
- Callsign detection requires at least one digit
- Group channels auto-bound to default backend for outbound
- Sidebar strips # and % from display labels
- Fix typo ARSDN → AREDN
New slash command to export the current channel's messages as a
downloadable file:
  /export       — plain text log (default)
  /export csv   — CSV with timestamp, from, message columns
  /export text  — plain text log

Server collects messages, formats them, and sends a /export event.
UI triggers a browser file download with the formatted data.
Filenames use channel name + timestamp, e.g. TacNet-20260604-220900.txt
ucode arrays don't support arbitrary property assignment — setting
_namekey on the command array was silently dropped, causing /export
to always show 'No channel selected'.
Xastir and YAAC 'Server Ports' emulate APRS-IS tier-two servers
and require the standard login handshake with a valid passcode
for write access. Without it, the connection is read-only and all
injected traffic is silently dropped.

Now all text-based backend types (aprsis, tcp_text, xastir, yaac)
send the login handshake on connect. The passcode field is optional
— omitting it or setting -1 gives receive-only access.

Also updated APRS.md with passcode documentation.
TCP connect to an unreachable mesh IP blocks the entire event loop
(default SYN timeout ~75s+). Sets SO_SNDTIMEO before connect to
fail fast, then clears it after successful connect.

Also added xastir_dzb4 backend (tcp_text) to docs and example config.
All text-based backends now send APRS-IS login handshake with passcode.
SO_SNDTIMEO doesn't work for connect timeout in ucode's socket API.
Switch to SOCK_NONBLOCK with async connect completion check:
- connect() returns immediately with EINPROGRESS
- handle() polls SO_ERROR to detect completion
- 10s deadline auto-retries with exponential backoff
- Prevents unreachable mesh IPs from blocking entire event loop
- Add APRS-IS login handshake for tcp_text/xastir/yaac backends
- Fix: add 5s connect timeout to prevent blocking on unreachable backends
- Fix: non-blocking connect for APRS backends
- Fix: use time() instead of now() in checkPendingConnect (scope issue)
Added new features guides for APRS bridge and Strict Gatekeeper mode.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants